module ActiveTest

  # ActiveTest::Controller is probably going to be the most-used Subject in your tests. It
  # specifies the most common situations of a controller, namely restful CRUD, and helps you
  # test the behaviour of the 7 most common actions: index, show, new, create, edit, update
  # and destroy.
  #
  # == Setup
  #
  # Probably the most useful class method you can call to dry up your controller is the deadly
  # one-liner, setup. It does a number of things, including setting up a bunch of
  # inflector values for your controller and the model assumed to be managed by it. You get
  # all these instance variables for free in your test:
  #
  # * +@request+ -- the Request object
  # * +@response+ -- the Response object
  # * +@controller+ -- the controller instance
  # * +@controller_name+ -- the controller class name
  # * +@controller_class+ -- the controller class constant
  # * +@model+ -- the model class constant
  # * +@model_class+ -- the model class constant
  # * +@model_name+ -- the model class name
  # * +@model_singular+ -- the assumed singular variable name (given Comment, gives comment)
  # * +@model_plural+ -- the assumed plural variable name (given Comment, gives comments)
  #
  # The model instance variables are then used for assuming certain instance variables are
  # assigned by your controller. For example, if you use the +assigns_records_on+ behaviour for
  # the index action in your FriendsController, it will automatically look for +@friends+ in
  # your template. You can use these instance variables throughout your edge case tests too.
  #
  # == Expectations & Assumptions
  #
  # Request Method Assumptions:
  #
  # * +index+, +show+, +new+ and +edit+ all use the GET HTTP request method.
  # * +create+ uses the POST HTTP request method.
  # * +update+ uses the PUT HTTP request method.
  # * +destroy+ uses the DELETE HTTP request method.
  #
  # *Note*: Any other action is assumed to be a collection GET request returning +@model_plural+.
  #
  # Response Assumptions:
  #
  #   TYPE      | ON SUCCESS      | ON FAILURE  | ON MISSING  |
  #   ----------+-----------------+-------------+-------------+
  #   get       | found (200)     | 404         | 404         |
  #   ----------+-----------------+-------------+-------------+
  #   post      | redirect (302)  | found (200) | 404         |
  #   ----------+-----------------+-------------+-------------+
  #   put       | redirect (302)  | found (200) | 404         |
  #   ----------+-----------------+-------------+-------------+
  #   delete    | redirect (302)  | 404         | 404         |
  #
  # If you do not use the standard +ActiveRecord::Base.find(params[:id])+, expecting
  # an ActiveRecord::RecordNotFound exception for non-existent records, there is an option for
  # this situation and, in fact, most unconventional paths, like different failure/success
  # responses and request methods.
  #
  # == Controller Behaviour
  #
  # ActiveTest breaks down controller behaviour into atomic tests of functionality, much the
  # same way that one would break apart tests normally. ActiveTest sees controllers responding
  # in the following ways:
  #
  # * response codes & templates
  # * variables for templates
  # * changes to models
  # * changes to sessions (not implemented yet)
  # * changes to flash (not implemented yet)
  # * changes to cache (not implemented yet)
  # * streaming data (not implemented yet)
  # * checking permissions (not included in the ActiveTest base package)
  #
  # Each of these groups have at least two class methods (checking for positive and negative)
  # which can be called within a test suite inheriting from ActiveTest::Controller.
  #
  # == Example
  #
  # Given the following is defined in your test suite,
  #
  #   succeeds_on :index
  #   fails_on :new, :redirect => :index_url
  #
  # ActiveTest will create the following tests:
  #
  #   def test_should_succeed_on_index
  #     get :index
  #     assert_response :success
  #     assert_template 'index'
  #   end
  #   def test_should_fail_on_new_and_redirect
  #     post :create
  #     assert_response :redirect
  #     assert_redirected_to :index_url
  #     assert_template 'new'
  #   end
  #
  class Controller < Subject
    
    class NoControllerFound < ActiveTestError #:nodoc:
    end
    
    class << self
      
      # Sets up a test suite for testing a controller. It automatically derives the
      # controller most likely to be the subject of testing (taken from the class name
      # of the test, such as SessionsControllerTest). From the controller it also
      # derives the most likely model, such as Session, and its singular and plural
      # forms, 'session' and 'sessions', which are stored in @model, @model_singular,
      # and @model_plural, respectively.
      #
      # Options:
      # * +:controller+ -- the class of the controller to be tested
      # * +:model+ -- the class of the model attached to this controller
      #
      # *Note*: An exception is thrown if no controller can be found.
      #
      # Example:
      #
      #   setup :controller => SessionsController, :model => User
      #
      def setup(options = {})
        define_method(:setup) do
          assumed = self.class.name[/^(\w+)Test/, 1].constantize rescue nil
          @controller_class = options.delete(:controller) || assumed
          raise NoControllerFound unless @controller_class
          @controller_name = @controller_class.name
          if options[:model] != :null
            assumed = @controller_name.sub('Controller', '').singularize.constantize rescue nil
            if (@model = (options.delete(:model) || assumed))
              @model_class, @model_name = @model, @model.name
              @model_singular = @model_name.underscore
              @model_plural = @model_singular.pluralize
            end
          end

          @controller = @controller_class.new
          @request    = ActionController::TestRequest.new
          @response   = ActionController::TestResponse.new
        end
      end
      
      # Generate a test case which checks a given action responds successfully.
      #
      # Options:
      # * +:parameters+ -- a hash of parameters to be passed as part of the request.
      # * +:redirect+ -- if a redirect is expected, the named url (e.g. :index_url) to assert
      #
      # *Note*: Data must be provided in +:parameters+ for actions like create and update.
      #
      def succeeds_on(action, options = {})
        define_behavioral(:succeed_on, action, options) do
          send("call_#{action}_action", options)
          send("assert_#{action}_success")
        end
      end
      
      # Generate a test case which checks a given action responds with a failure.
      #
      # Options:
      # * +:parameters+ -- a hash of parameters to be passed as part of the request.
      # * +:redirect+ -- if a redirect is expected, the named url (e.g. :index_url) to assert
      # * +:raises+ -- set to the constant being raised, defaults to ActiveRecord::RecordNotFound
      #   for show, edit, update and destroy actions.
      #
      def fails_on(action, options = {})
        if action.to_s =~ /show|edit|update|destroy/
          options[:raises] = ActiveRecord::RecordNotFound unless options[:raises]
        end
        define_behavioral(:fail_on, action, options) do
          if options[:raises]
            assert_raises(options[:raises]) do
              send("call_#{action}_action", options)
            end
          else
            send("call_#{action}_action", options)
            send("assert_#{action}_failure")
          end
        end
      end
      
      # Generates a test case which checks a given action assigns an empty variable (defaults
      # to the appropriate model variable -- e.g. for CommentsController#index, checks @comments).
      #
      # Options:
      # * +:requirements+ -- a Proc or attribute which returns true, to be run on the returned
      #   variable. This is meant to check scoped collections or single items, e.g. comments all
      #   having the same user id.
      # * +:with+ -- name of the instance variable to be returned by this action. This is
      #   only necessary if it differs from the assumed model name -- see setup).
      #
      def assigns_records_on(action, options = {})
        define_behavioral(:assign_records_on, action, options) do
          send("call_#{action}_action", options)
          assert_assigned((options[:with] || plural_or_singular(action)).to_sym)
        end
      end
      
      # Generates a test case which checks a given action assigns an empty variable.
      #
      # Options:
      # * +:null+ -- set to true to check the variable is nil instead of empty
      #
      def assigns_empty_on(action, options = {})
        define_behavioral(:assign_empty_on, action, options) do
          send("call_#{action}_action", options)
          assert_not_assigned((options[:with] || plural_or_singular(action)).to_sym)
        end
      end

      # Generates a test case which checks a given action successfully creates a record.
      #
      # Options:
      # * +:model+ -- (default: @model) model class constant
      # * +:count+ -- (default: 1) number of records created
      # * +:with+ -- name of the instance variable to be returned by this action. This is
      #   only necessary if it differs from the assumed model name -- see setup).
      #
      def creates_record_on(action, options = {})
        define_behavioral(:create_record_on, action, options) do
          assert_difference((options[:model] || @model), :count, (options[:count] || 1)) do
            send("call_#{action}_action", options)
            var_name = (options[:with] || plural_or_singular(action))
            assert_valid @controller.instance_variable_get("@#{var_name}")
          end
        end
      end
      
      # Generates a test case which checks a given action successfully updates a record.
      #
      # Options:
      # * +:model+ -- (default: @model) model class constant
      # * +:with+ -- name of the instance variable to be returned by this action. This is
      #   only necessary if it differs from the assumed model name -- see setup).
      #
      def updates_record_on(action, options = {})
        define_behavioral(:update_record_on, action, options) do
          send("call_#{action}_action", options)
          var_name = (options[:with] || plural_or_singular(action))
          assert_valid @controller.instance_variable_get("@#{var_name}")
        end
      end
      
      # Generates a test case which checks a given action successfully deletes a record.
      #
      # Options:
      # * +:model+ -- (default: @model) model class constant
      # * +:count+ -- (default: -1) number of records deleted
      #
      def deletes_record_on(action, options = {})
        define_behavioral(:delete_record_on, action, options) do
          assert_difference((options[:model] || @model), :count, (options[:count] || -1)) do
            send("call_#{action}_action", options)
          end
        end
      end
      
      # Generates a test case which checks a given action fails to change a record.
      #
      # Options:
      # * +:model+ -- (default: @model) model class constant
      #
      def record_unchanged_on(action, options = {})
      end
      
      # not implemented yet
      def changes_session_on(action, session, options = {})
      end

      # not implemented yet
      def changes_flash_on(action, key, options = {})
      end

      # not implemented yet
      def caches_page_on(action, options = {})
      end
      
      # not implemented yet
      def caches_action_on(action, options = {})
      end
      
      # not implemented yet
      def streams_data_on(action, options = {})
      end
      
      def determine_request_method(action)
        case action.to_s
        when /destroy|delete/ then :delete
        when /update/ then :put
        when /create/ then :post
        else :get
        end
      end
      
      def type_of(action)
        case action
        when :new then :new
        when :create then :new
        when :show then :member
        when :edit then :member
        when :update then :member
        when :destroy then :member
        else :collector
        end
      end

    end

    # Generic Responses
    
    def assert_restful_get_success(action, options = {})
      assert_response :success
      assert_template action.to_s
    end
    def assert_restful_get_failure(action, options = {})
      assert_response :missing
    end

    def assert_restful_post_success(action, options = {})
      assert_response :redirect
      assert_redirected_to redirect if redirect = options.delete(:redirect)
    end
    def assert_restful_post_failure(action, options = {})
      assert_response :success
    end

    def assert_restful_put_success(action, options = {})
      assert_response :redirect
      assert_redirected_to redirect if redirect = options.delete(:redirect)
    end
    def assert_restful_put_failure(action, options = {})
      assert_response :success
    end
    
    def assert_restful_delete_success(action, options = {})
      assert_response :redirect
      assert_redirected_to redirect if redirect = options.delete(:redirect)
    end
    def assert_restful_delete_failure(action, options = {})
      assert_response :missing
    end
    
    # Helpers

    def plural_or_singular(action)
      self.class.type_of(action) == :collector ? @model_plural : @model_singular
    end
    
    def expand_options(options)
      parameters = options[:parameters]
      parameters = send(parameters) if parameters.is_a?(Symbol)
      parameters = instance_eval(&parameters) if parameters.is_a?(Proc)
      options[:parameters] = parameters
      options
    end
    
    protected

    def method_missing(method, *args)
      options = args.last.is_a?(Hash) ? args.dup.pop : {}
      if method.to_s =~ /^assert_(.*)_(success|failure)$/
        action, sf = $1, $2
        if action !~ /get|post|put|delete/
          request_method = self.class.determine_request_method(action)
          send("assert_restful_#{request_method}_#{sf}", action, options)
        end
      elsif method.to_s =~ /^call_(.*)_action$/
        request_method = self.class.determine_request_method(action = $1)
        send("call_request_method", request_method, action, options)
      else
        super
      end
    end

    # Restful request method callers
    def call_request_method(request_method, action, options = {})
      options = expand_options(options)
      send(request_method, action, options[:parameters])
    end

  end
end